Skip to content

fix(samples): parse the unified v2 error envelope on import failures - #21

Merged
mhusbynflow merged 1 commit into
masterfrom
mhusbynflow/sample-import-error-compat
Aug 7, 2026
Merged

fix(samples): parse the unified v2 error envelope on import failures#21
mhusbynflow merged 1 commit into
masterfrom
mhusbynflow/sample-import-error-compat

Conversation

@mhusbynflow

Copy link
Copy Markdown
Collaborator

What

flow-api master now returns a unified error envelope for every v2 endpoint:

{"error": {"code": "validation_error",
           "message": "Invalid sample import request",
           "details": [{"field": "0.sample_type", "code": "invalid",
                        "message": "Sample type 'bogus' does not exist"}]}}

Previously error was a bare string. The client's transport took body["error"] verbatim as the error message, so on a bad sample import the CLI dumped a raw Python dict repr and the per-field details list — the whole point of the server-side unification — never reached the renderer.

Change

  • _transport.py_unpack_error_body detects the {code, message, details} envelope, extracting the human message and details in one place that covers both /v2/sample-imports endpoints (import_samples, get_import).
  • exceptions.pyFlowApiError carries optional details.
  • _main.py — passes error.details to the renderer (was AnnotationValidationError-only).
  • _output.pyformat_issue prefixes envelope details with their field; annotation row N: rendering preserved.
  • tests — both v2 endpoints (422 import rejection, 404 import-status) assert against the real envelope shape, that details render one-per-line, and that no dict-repr leaks.
  • Version bump 0.11.00.11.1.

A bad import now renders:

Error: Invalid sample import request
  0.sample_type: Sample type 'bogus' does not exist

Compatibility

Backwards-compatible. The unwrap only fires when error is a dict with a message key — which only the v2 envelope produces. Legacy (non-v2) endpoints returning flat strings, and the annotation endpoint's {"validation"/"warnings": [...]} dicts, are unaffected. All 393 unit tests pass.

🤖 Generated with Claude Code

flow-api's v2 API now returns every error as a {code, message, details}
envelope under "error", where it previously returned a bare string. The
transport blindly took body["error"] as the message, so a bad sample import
surfaced as a raw Python dict repr in the CLI and the per-field details list
— the whole point of the server-side unification — never reached the renderer.

Unwrap the envelope in one place in the transport (covering both
/v2/sample-imports endpoints), carry its details on FlowApiError, and render
each detail on its own field-prefixed line. Legacy endpoints returning a flat
string, and the annotation endpoint's {"validation"/"warnings": [...]} dicts,
are untouched: the unwrap only fires when "error" is a dict with a "message"
key, which only the v2 envelope produces.

Bump to 0.11.1 (backwards-compatible fix).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mhusbynflow
mhusbynflow merged commit 6c525dd into master Aug 7, 2026
4 checks passed
Comment thread flowbio/cli/_main.py
return int(ExitCode.USAGE)
except FlowApiError as error:
details = error.errors if isinstance(error, AnnotationValidationError) else None
details = error.errors if isinstance(error, AnnotationValidationError) else error.details

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OPTIONAL — now that FlowApiError owns a details channel, there are two attributes carrying the same concept and the caller has to isinstance-switch between them. AnnotationValidationError sets self.errors but leaves self.details at None, so "an annotation error has no details" is a representable-but-wrong state that only this branch papers over — and any future call site that reads error.details will silently lose annotation errors.

Passing them through the base channel makes the switch unnecessary:

# exceptions.py
def __init__(self, errors: list[dict]) -> None:
    self.errors = errors
    super().__init__(
        HTTPStatus.BAD_REQUEST,
        f"Annotation has {len(errors)} validation error(s)",
        details=errors,
    )
Suggested change
details = error.errors if isinstance(error, AnnotationValidationError) else error.details
details = error.details

.errors stays as the specific, tested alias for library users; _dispatch stops needing to know the subclass exists (and the AnnotationValidationError import here likely becomes unused).

Comment thread flowbio/v2/exceptions.py
self,
status_code: int,
message: str | dict[str, list[str]],
details: list[dict] | None = None,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OPTIONALlist[dict] is a new public attribute typed as an unparameterised dict, while the docstring right above it states the actual shape ({"field", "code", "message"}). CLAUDE.md's typing rule is to type the actual shape rather than a catch-all, and the shape currently only exists as prose — _output.format_issue then rediscovers it by probing keys at runtime.

A TypedDict (or a frozen model, matching the convention elsewhere in v2) would carry it in the type system: class ErrorDetail(TypedDict): field: str; code: str; message: str, then details: list[ErrorDetail] | None. Worth doing here because the envelope is now the server's standard error shape, so this list will be read by SDK users directly, not just by the CLI renderer.

assert result.exit_code == 1
assert route.call_count == 1
assert "bogus" in result.stderr
assert "Invalid sample import request" in result.stderr

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BLOCKING — the new behaviour is only asserted through CLI stderr text on two sample-import commands, but the unwrap lives in the transport and FlowApiError.details is a public attribute of a client library. tests/unit/v2/test_transport.py and tests/unit/v2/test_exceptions.py currently contain no reference to details at all, so nothing pins:

  • the positive library-level case — a v2 envelope response raises with message == "Invalid sample import request" and details == [{...}] (this is what an SDK consumer, as opposed to the CLI, actually reads);
  • the compat case the PR description rests on — a legacy {"error": "flat string"} body still yields details is None. Existing transport tests would catch a broken message, but nothing would catch details starting to pick up junk from a non-envelope body.

Exercising through the CLI is fine per CLAUDE.md, but transport.post/get are public entry points too and test_transport.py is where every other status-to-exception mapping is pinned. Two small tests there (envelope → populated details; flat string → None) would close it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant